李守中

Docker 容器构建

Table of Contents

1. Scratch 基础镜像

scratch 是一个虚拟镜像,不能被 pull,也不能运行。

这就意味着新镜像的构建是从零开始,不存在其他的镜像层。例如:

FROM golang
COPY hello.go .
RUN go build hello.go

FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]

这一次构建的镜像大小就是 2 MB,够小。

但是,使用 scratch 作为基础镜像时会带来很多的不便。

1.1. 缺少 shell

这就意味着 CMD 和 RUN 命令中不能使用字符串,不能这么写:

FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

如果用以上命令构建镜像,并将其运行为容器,就会遇到下面的报错:

docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"/bin/sh\": stat /bin/sh: no such file or directory": unknown.

从报错信息可以看出,镜像中并不包含 /bin/sh,所以无法运行程序。

因为在 CMD, RUN 命令中使用字符串作为参数时,这些参数会被放到 /bin/sh 中执行,也就是说,下面这两条语句是等效的:

  • CMD ./hello
  • CMD /bin/sh -c "./hello"

解决办法是: 使用 JSON 语法取代字符串语法

比如,将 CMD ./hello 替换为 CMD ["./hello"]

1.2. 缺少调试工具

scratch 镜像不包含任何调试工具。ls, ps, ping, shell 都没有。

无法使用 docker exec 进入容器,也无法查看网络堆栈信息等等。

  1. 查看容器中的文件,可以使用 docker cp
  2. 查看或调试网络堆栈,可以使用 docker run --net container 或者使用 nsenter
  3. 为了更好地调试容器,Kubernetes 也引入了一个新概念叫 Ephemeral Containers ,但现在还是 Alpha 特性。

虽然有这些方法可以调试容器,但它们比较麻烦。

折中一下可以选择 busybox 或 alpine 镜像来替代 scratch。

虽然它们多了几 MB,但从整体来看,牺牲少量的空间来换取调试的便利,还是很值得的。

1.3. 缺少 libc

这是最难解决的问题。

scratch 作为基础镜像,Go 语言版本的 hello world 跑得很欢快,C 语言版本就不行了。或者换个更复杂的 Go 程序也是跑不起来的 ( 例如用到了网络相关的工具包 ),会遇到类似于下面的错误:

standard_init_linux.go:211: exec user process caused "no such file or directory"

从报错信息可以看出缺少文件,但没有指明到底缺少哪些文件,其实这些文件就是程序运行所必需的动态库 ( dynamic library )。

1.3.1. 什么是动态库

所谓动态库、静态库,指的是程序编译的链接阶段,链接成可执行文件的方式。

静态库指的是在链接阶段将汇编生成的 .o 目标文件与引用到的库一起链接打包到可执行文件中,因此对应的链接方式称为静态链接 ( static linking )。

而动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入,因此对应的链接方式称为动态链接 ( dynamic linking )。

90 年代的程序大多使用的是静态链接,因为当时的程序大多数都运行在软盘或者盒式磁带上,而且当时根本不存在标准库。

这样程序在运行时与函数库再无瓜葛,移植方便。但对于 Linux 这样的分时系统,会在在同一块硬盘上并发运行多个程序,这些程序基本上都会用到标准的 C 库,这时使用动态链接的优点就体现出来了。

1.3.2. 为什么需要动态库

使用动态链接时,可执行文件不包含标准库文件,只包含到这些库文件的索引。

例如,某程序依赖于库文件 libtrigonometry.so 中的 cos 和 sin 函数,该程序运行时就会根据索引找到并加载 libtrigonometry.so,然后程序就可以调用这个库文件中的函数。

使用动态链接的好处显而易见:

  1. 节省磁盘空间。不同的程序可以共享常见的库;
  2. 节省内存。共享的库只需从磁盘中加载到内存一次,然后在不同的程序之间共享;
  3. 便于维护,库文件更新后,不需要重新编译使用该库的所有程序。

严格来说,动态库与共享库 ( shared libraries ) 相结合才能达到节省内存的功效。Linux 中动态库的扩展名是 .so ( shared object ),而 Windows 中动态库的扩展名是 .DLL ( Dynamic-link library )。

回到最初的问题,默认情况下,C 程序使用的是动态链接,Go 程序也是。

上面的 hello world 程序使用了标准库文件 libc.so.6,所以只有镜像中包含该文件,程序才能正常运行。

使用 scratch 作为基础镜像肯定是不行的,使用 busybox 和 alpine 也不行,因为 busybox 不包含标准库,而 alpine 使用的标准库是 musl libc ,与常用的标准库 glibc 不兼容。

1.3.3. 解决标准库的问题的三种方案

1.3.3.1. 方案一: 使用静态库

我们可以让编译器使用静态库编译程序,办法有很多,如果使用 gcc 作为编译器,只需加上一个参数 gcc -o hello hello.c -static

编译完的可执行文件大小为 760 kB,相比于之前的 16kB 是大了好多,这是因为可执行文件中包含了其运行所需要的库文件。编译完的程序就可以跑在 scratch 镜像中了。

如果使用 alpine 镜像作为基础镜像来编译,得到的可执行文件会更小 ( < 100kB ),下篇文章会详述。

1.3.3.2. 方案二: 拷贝库文件到镜像中

为了找出程序运行需要哪些库文件,可以使用 ldd 工具:

$ ldd hello
linux-vdso.so.1 (0x00007ffdf8acb000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007ff897ef6000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007ff8980f7000)

从输出结果可知,该程序只需要 libc.so.6 这一个库文件。

linux-vdso.so.1 与一种叫做 VDSO 的机制有关,用来加速某些系统调用,可有可无。

ld-linux-x86-64.so.2 表示动态链接器本身,包含了所有依赖的库文件的信息。

可以选择将 ldd 列出的所有库文件拷贝到镜像中,但以后会很难维护,特别是当程序有大量依赖库时。

对于 hello world 程序来说,拷贝库文件完全没有问题,但对于更复杂的程序 ( 例如使用到 DNS 的程序 ),就会遇到令人费解的问题:

glibc ( GNU C library ) 通过一种相当复杂的机制来实现 DNS,这种机制叫 NSS ( Name Service Switch, 名称服务开关 )。它需要一个配置文件 /etc/nsswitch.conf 和额外的函数库。

但使用 ldd 时不会显示这些函数库,因为这些库在程序运行后才会加载。如果想让 DNS 解析正确工作,必须要拷贝这些额外的库文件 ( /lib64/libnss_* )。

所以不到万不得已,不要直接拷贝库文件,因为它非常难维护,后期可能需要不断地更改,而且还有很多未知的隐患。

1.3.3.3. 方案三: 使用 busybox:glibc 作为基础镜像

有一个镜像可以完美解决所有的这些问题,那就是 busybox:glibc。

它只有 5 MB 大小,并且包含了 glibc 和各种调试工具。如果需要一个镜像来运行使用动态链接的程序,busybox:glibc 是最好的选择。

注意: 如果程序使用到了除标准库之外的库,仍然需要将这些库文件拷贝到镜像中。

2. Alpine 基础镜像

与 CentOS 和 Ubuntu 之类的基础镜像不同,Alpine 并没有像 Red Hat 或 Canonical 之类的大公司为其提供维护支持,软件包的数量也比这些发行版少很多。

如果只看开箱即用的默认软件仓库,Alpine 只有 10000 个软件包,而 Ubuntu、Debian 和 Fedora 的软件包数量均大于 50000。

要将 CentOS 和 Ubuntu 等镜像体积控制在 100M 以下,只能通过删除某些工具 ( 例如 ifconfig 和 netstat )。而对于 Alpine 而言本身大小也就只有 5M 而已。

Alpine 镜像的另一个优势是包管理工具的执行速度非常快,安装软件体验非常顺滑。

但要使用 Alpine 作为基础镜像需要分 2 种情况来看,它们有不同的坑:

  1. Alpine 作为第二构建阶段 ( run 阶段 ) 的基础镜像;
  2. ALpine 作为所有构建阶段 ( run 阶段和 build 阶段 ) 的基础镜像。

2.1. 第二阶段起使用 Alpine

举个例子,给一个 Dockerfile:

FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c

FROM alpine
COPY --from=mybuildstage hello .
CMD ["./hello"]

启动容器出现了错误:

standard_init_linux.go:211: exec user process caused "no such file or directory"

前面提到 scratch 镜像作为 C 语言程序的基础镜像时,程序运行报错的原因是 scratch 镜像中缺少动态库文件。

Alpine 镜像与 scratch 镜像类似。

Alpine 使用的也是动态库,毕竟它的设计目标之一就是占用更少的空间。

但 Alpine 使用的标准库与大多数发行版不同,它使用的是 musl libc,这个库相比于 glibc 更小、更简单、更安全, 但是与大家常用的标准库 glibc 并不兼容

不用 musl libc 代替 glibc 的原因是,glibc 有很多扩展库,并且很多程序都用到了这些扩展库,但 musl libc 不包含这些扩展库。

也就是说, 如果想让程序跑在 Alpine 镜像中,必须在编译时使用 musl libc 作为动态库

2.2. 所有阶段使用 Alpine

为了生成一个与 musl libc 链接的二进制文件,有两条路:

  1. 某些官方镜像提供了 Alpine 版本,可以直接拿来用;
  2. 还有些官方镜像没有提供 Alpine 版本,我们需要自己构建。

2.2.1. Golang 的 Alpine 镜像

Golang 镜像就属于第一种情况,golang:alpine 提供了基于 Alpine 构建的 Go 工具链。

构建 Go 程序可以使用下面的 Dockerfile:

FROM golang:alpine
COPY hello.go .
RUN go build hello.go

FROM alpine
COPY --from=0 /go/hello .
CMD ["./hello"]

结果优势很明显:

  1. 生成的镜像大小为 7.5M;
  2. 即使程序很复杂,生成的镜像也不会很大;
  3. 包含了很多有用的调试工具;
  4. 即使运行时缺少某些特殊的调试工具,也可以迅速安装。

2.2.2. 自制 C 语言的 Alpine 镜像

C 语言并没有 gcc:alpine 这样的镜像,所以只能以 Alpine 镜像作为基础镜像,自己安装 C 编译器了,Dockerfile 如下:

FROM alpine
RUN apk add build-base
COPY hello.c .
RUN gcc -o hello hello.c

FROM alpine
COPY --from=0 hello .
CMD ["./hello"]

必须安装 build-base ,如果安装 gcc,就只有编译器,没有标准库。

build-base 相当于 Ubuntu 的 build-essentials ,引入了编译器、标准库和 make 之类的工具。

3. Java 语言镜像精简

Java 属于编译型语言,但运行时还是要跑在 JVM 中。

从概念上来看,Java 使用的是动态链接,因为 Java 代码需要调用 JVM 提供的 Java API,这些 API 的代码都在可执行文件之外,通常是 JAR 文件或 WAR 文件。

而这些 Java 库并不是完全独立于系统库的,某些 Java 函数最终还是会调用系统库,例如打开文件时需要调用 open() fopen() 或它们的变体。

因此 JVM 本身可能会与系统库动态链接。

这就意味着理论上可以使用任意带标准库与 JVM 的镜像来运行 Java 程序,系统标准库是 musl libc 还是 glibc 都无所谓。

因此,也就可以使用任意带有 JVM 的基础镜像来构建 Java 程序,也可以使用任意带有 JVM 的镜像作为运行 Java 程序的基础镜像。

4. 解释型语言镜像精简

对于诸如 Node、Python、Rust 之类的解释型语言来说,情况就比较复杂一点了。

4.1. Alpine 镜像

对于解释型语言来说,如果程序仅用到了标准库或者依赖项和程序本身使用的是同一种语言,且无需调用 C 库和外部依赖,那么使用 Alpine 作为基础镜像一般是没有啥问题的。

一旦程序需要调用外部依赖,情况就复杂了,想继续使用 Alpine 镜像,就得安装这些依赖。

根据难度可以划分为三个等级:

  1. 简单: 依赖库有针对 Alpine 的安装说明,一般会说明需要安装哪些软件包以及如何建立依赖关系。但这种情况非常罕见,原因前面也提到了,Alpine 的软件包数量比大多数流行的发行版要少得多。
  2. 中等: 依赖库没有针对 Alpine 的安装说明,但有针对别的发行版的安装说明。可以通过对比找到与别的发行版的软件包相匹配的 Alpine 软件包 ( 假如有的话 )。
  3. 困难: 依赖库没有针对 Alpine 的安装说明,但有针对别的发行版的安装说明,但是 Alpine 也没有与之对应的软件包。这种情况就必须从源码开始构建!

最后一种情况最不推荐使用 Alpine 作为基础镜像,不但不能减小体积,可能还会适得其反,因为你需要安装编译器、依赖库、头文件等等……更重要的是,构建时间会很长,效率低下。

如果非要考虑多阶段构建,就更复杂了,你得搞清楚如何将所有的依赖编译成二进制文件,想想就头大。因此一般不推荐在解释型语言中使用多阶段构建。

有一种特殊情况会同时遇到 Alpine 的绝大多数问题: 将 Python 用于数据科学。

numpy 和 pandas 之类的包都被预编译成了 wheel。wheel 是 Python 新的打包格式,是二进制格式,用于替代 Python 传统的 egg 文件,可以通过 pip install <package_name> 直接安装。

但这些 wheel 都绑定了特定的 C 库,这就意味着在大多数使用 glibc 的镜像中都可以正常安装,但 Alpine 镜像就不行,原因前面已经说过了。

如果非要在 Alpine 中安装,需要安装很多依赖,从头构建,耗时又费力。

也不是所有 Python 程序都不推荐用 Alpine,至少 Python 用于数据科学时不推荐使用 Alpine,其他情况还是可以试一试 Alpine 的。

4.2. <image_name>:slim 镜像

如果实在不想折腾,可以选择一个折衷的镜像 <image_name>:slim。slim 镜像一般都基于 Debian 和 glibc,删除了许多非必需的软件包,优化了体积。比如:

  • python:3.7.9-slim-buster 镜像大小 40.37 MB;
  • python:3.7.9-buster 镜像大小 321.27 MB。

如果构建过程中需要编译器,那么 slim 镜像不适合,除此之外大多数情况下还是可以使用 slim 作为基础镜像的。



Last Update: 2023-08-13 Sun 14:25

Generated by: Emacs 28.2 (Org mode 9.5.5)   Contact: [email protected]

若正文中无特殊说明,本站内容遵循: 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议